"""passlib.bcrypt -- implementation of OpenBSD's BCrypt algorithm.

TODO:

* support 2x and altered-2a hashes?
  http://www.openwall.com/lists/oss-security/2011/06/27/9

* deal with lack of PY3-compatibile c-ext implementation
"""
#=============================================================================
# imports
#=============================================================================
from __future__ import with_statement, absolute_import
# core
from base64 import b64encode
from hashlib import sha256
import os
import re
import logging; log = logging.getLogger(__name__)
from warnings import warn
# site
_bcrypt = None # dynamically imported by _load_backend_bcrypt()
_pybcrypt = None # dynamically imported by _load_backend_pybcrypt()
_bcryptor_engine = None # dynamically imported by _load_backend_bcryptor()
# pkg
_builtin_bcrypt = None  # dynamically imported by _load_backend_builtin()
from passlib.exc import PasslibHashWarning
from passlib.utils import bcrypt64, safe_crypt, repeat_string, to_bytes, parse_version, \
                          rng, getrandstr, test_crypt, to_unicode
from passlib.utils.compat import bytes, b, u, uascii_to_str, unicode, str_to_uascii
import passlib.utils.handlers as uh

# local
__all__ = [
    "bcrypt",
]

#=============================================================================
# support funcs & constants
#=============================================================================
IDENT_2 = u("$2$")
IDENT_2A = u("$2a$")
IDENT_2X = u("$2x$")
IDENT_2Y = u("$2y$")
_BNULL = b('\x00')

#=============================================================================
# handler
#=============================================================================
class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.GenericHandler):
    """This class implements the BCrypt password hash, and follows the :ref:`password-hash-api`.

    It supports a fixed-length salt, and a variable number of rounds.

    The :meth:`~passlib.ifc.PasswordHash.encrypt` and :meth:`~passlib.ifc.PasswordHash.genconfig` methods accept the following optional keywords:

    :type salt: str
    :param salt:
        Optional salt string.
        If not specified, one will be autogenerated (this is recommended).
        If specified, it must be 22 characters, drawn from the regexp range ``[./0-9A-Za-z]``.

    :type rounds: int
    :param rounds:
        Optional number of rounds to use.
        Defaults to 12, must be between 4 and 31, inclusive.
        This value is logarithmic, the actual number of iterations used will be :samp:`2**{rounds}`
        -- increasing the rounds by +1 will double the amount of time taken.

    :type ident: str
    :param ident:
        Specifies which version of the BCrypt algorithm will be used when creating a new hash.
        Typically this option is not needed, as the default (``"2a"``) is usually the correct choice.
        If specified, it must be one of the following:

        * ``"2"`` - the first revision of BCrypt, which suffers from a minor security flaw and is generally not used anymore.
        * ``"2a"`` - latest revision of the official BCrypt algorithm, and the current default.
        * ``"2y"`` - format specific to the *crypt_blowfish* BCrypt implementation,
          identical to ``"2a"`` in all but name.

    :type relaxed: bool
    :param relaxed:
        By default, providing an invalid value for one of the other
        keywords will result in a :exc:`ValueError`. If ``relaxed=True``,
        and the error can be corrected, a :exc:`~passlib.exc.PasslibHashWarning`
        will be issued instead. Correctable errors include ``rounds``
        that are too small or too large, and ``salt`` strings that are too long.

        .. versionadded:: 1.6

    .. versionchanged:: 1.6
        This class now supports ``"2y"`` hashes, and recognizes
        (but does not support) the broken ``"2x"`` hashes.
        (see the :ref:`crypt_blowfish bug <crypt-blowfish-bug>`
        for details).

    .. versionchanged:: 1.6
        Added a pure-python backend.
    """

    #===================================================================
    # class attrs
    #===================================================================
    #--GenericHandler--
    name = "bcrypt"
    setting_kwds = ("salt", "rounds", "ident")
    checksum_size = 31
    checksum_chars = bcrypt64.charmap

    #--HasManyIdents--
    default_ident = IDENT_2A
    ident_values = (IDENT_2, IDENT_2A, IDENT_2X, IDENT_2Y)
    ident_aliases = {u("2"): IDENT_2, u("2a"): IDENT_2A,  u("2y"): IDENT_2Y}

    #--HasSalt--
    min_salt_size = max_salt_size = 22
    salt_chars = bcrypt64.charmap
        # NOTE: 22nd salt char must be in bcrypt64._padinfo2[1], not full charmap

    #--HasRounds--
    default_rounds = 12 # current passlib default
    min_rounds = 4 # minimum from bcrypt specification
    max_rounds = 31 # 32-bit integer limit (since real_rounds=1<<rounds)
    rounds_cost = "log2"

    #===================================================================
    # formatting
    #===================================================================

    @classmethod
    def from_string(cls, hash):
        ident, tail = cls._parse_ident(hash)
        if ident == IDENT_2X:
            raise ValueError("crypt_blowfish's buggy '2x' hashes are not "
                             "currently supported")
        rounds_str, data = tail.split(u("$"))
        rounds = int(rounds_str)
        if rounds_str != u('%02d') % (rounds,):
            raise uh.exc.MalformedHashError(cls, "malformed cost field")
        salt, chk = data[:22], data[22:]
        return cls(
            rounds=rounds,
            salt=salt,
            checksum=chk or None,
            ident=ident,
        )

    def to_string(self):
        hash = u("%s%02d$%s%s") % (self.ident, self.rounds, self.salt,
                                   self.checksum or u(''))
        return uascii_to_str(hash)

    def _get_config(self, ident=None):
        """internal helper to prepare config string for backends"""
        if ident is None:
            ident = self.ident
        if ident == IDENT_2Y:
            # none of passlib's backends suffered from crypt_blowfish's
            # buggy "2a" hash, which means we can safely implement
            # crypt_blowfish's "2y" hash by passing "2a" to the backends.
            ident = IDENT_2A
        else:
            # no backends currently support 2x, but that should have
            # been caught earlier in from_string()
            assert ident != IDENT_2X
        config = u("%s%02d$%s") % (ident, self.rounds, self.salt)
        return uascii_to_str(config)

    #===================================================================
    # specialized salt generation - fixes passlib issue 25
    #===================================================================

    @classmethod
    def _bind_needs_update(cls, **settings):
        return cls._needs_update

    @classmethod
    def _needs_update(cls, hash, secret):
        if isinstance(hash, bytes):
            hash = hash.decode("ascii")
        # check for incorrect padding bits (passlib issue 25)
        if hash.startswith(IDENT_2A) and hash[28] not in bcrypt64._padinfo2[1]:
            return True
        # TODO: try to detect incorrect $2x$ hashes using *secret*
        return False

    @classmethod
    def normhash(cls, hash):
        """helper to normalize hash, correcting any bcrypt padding bits"""
        if cls.identify(hash):
            return cls.from_string(hash).to_string()
        else:
            return hash

    def _generate_salt(self, salt_size):
        # generate random salt as normal,
        # but repair last char so the padding bits always decode to zero.
        salt = super(bcrypt, self)._generate_salt(salt_size)
        return bcrypt64.repair_unused(salt)

    def _norm_salt(self, salt, **kwds):
        salt = super(bcrypt, self)._norm_salt(salt, **kwds)
        assert salt is not None, "HasSalt didn't generate new salt!"
        changed, salt = bcrypt64.check_repair_unused(salt)
        if changed:
            # FIXME: if salt was provided by user, this message won't be
            # correct. not sure if we want to throw error, or use different warning.
            warn(
                "encountered a bcrypt salt with incorrectly set padding bits; "
                "you may want to use bcrypt.normhash() "
                "to fix this; see Passlib 1.5.3 changelog.",
                PasslibHashWarning)
        return salt

    def _norm_checksum(self, checksum):
        checksum = super(bcrypt, self)._norm_checksum(checksum)
        if not checksum:
            return None
        changed, checksum = bcrypt64.check_repair_unused(checksum)
        if changed:
            warn(
                "encountered a bcrypt hash with incorrectly set padding bits; "
                "you may want to use bcrypt.normhash() "
                "to fix this; see Passlib 1.5.3 changelog.",
                PasslibHashWarning)
        return checksum

    #===================================================================
    # backend configuration
    #===================================================================

    backends = ("bcrypt", "pybcrypt", "bcryptor", "os_crypt", "builtin")

    # appended to HasManyBackends' "no backends available" error message
    _no_backend_suggestion = " -- recommend you install one (e.g. 'pip install bcrypt')"

    def _calc_checksum(self, secret):
        "common backend code"
        if isinstance(secret, unicode):
            secret = secret.encode("utf-8")
        if _BNULL in secret:
            # NOTE: especially important to forbid NULLs for bcrypt, since many
            # backends (bcryptor, bcrypt) happily accept them, and then
            # silently truncate the password at first NULL they encounter!
            raise uh.exc.NullPasswordError(self)
        return self._calc_checksum_backend(secret)

    #---------------------------------------------------------------
    # bcrypt backend
    #---------------------------------------------------------------
    @classmethod
    def _load_backend_bcrypt(cls):
        # try to import bcrypt
        global _bcrypt
        try:
            import bcrypt as _bcrypt
        except ImportError: # pragma: no cover
            return None
        if not hasattr(_bcrypt, "_ffi"):
            # pybcrypt was installed instead
            _bcrypt = None
            return None
        return cls._calc_checksum_bcrypt

    def _calc_checksum_bcrypt(self, secret):
        # bcrypt behavior:
        #   hash must be ascii bytes
        #   secret must be bytes
        #   returns bytes
        if self.ident == IDENT_2:
            # bcrypt doesn't support $2$ hashes; but we can fake $2$ behavior
            # using the $2a$ algorithm, by repeating the password until
            # it's at least 72 chars in length.
            if secret:
                secret = repeat_string(secret, 72)
            config = self._get_config(IDENT_2A)
        else:
            config = self._get_config()
        if isinstance(config, unicode):
            config = config.encode("ascii")
        hash = _bcrypt.hashpw(secret, config)
        assert hash.startswith(config) and len(hash) == len(config)+31
        assert isinstance(hash, bytes)
        return hash[-31:].decode("ascii")

    #---------------------------------------------------------------
    # pybcrypt backend
    #---------------------------------------------------------------

    #: classwide thread lock used for pybcrypt < 0.3
    _calc_lock = None

    @classmethod
    def _load_backend_pybcrypt(cls):
        # try to import pybcrypt
        global _pybcrypt
        try:
            import bcrypt as _pybcrypt
        except ImportError: # pragma: no cover
            return None
        if hasattr(_pybcrypt, "_ffi"):
            # bcrypt was installed instead
            _pybcrypt = None
            return None

        # determine pybcrypt version
        try:
            from bcrypt._bcrypt import __version__ as vstr
        except ImportError:
            log.warning("couldn't determine pybcrypt version", exc_info=True)
            vstr = "?.?"
        version = parse_version(vstr)
        if not version:
            log.warning("couldn't parse pybcrypt version string: %r", vstr)
            version = (0, 0)
        log.debug("found pybcrypt version %s", vstr)

        # return calc function based on version
        if version < (0,3):
            warn("py-bcrypt %s has a major security vulnerability, "
                 "you should upgrade to py-bcrypt 0.3 immediately."
                 % vstr, uh.exc.PasslibSecurityWarning)
            if cls._calc_lock is None:
                import threading
                cls._calc_lock = threading.Lock()
            return cls._calc_checksum_pybcrypt_threadsafe
        else:
            return cls._calc_checksum_pybcrypt

    def _calc_checksum_pybcrypt_threadsafe(self, secret):
        # as workaround for pybcrypt < 0.3's concurrency issue,
        # we wrap everything in a thread lock. as long as bcrypt is only
        # used through passlib, this should be safe.
        with self._calc_lock:
            return self._calc_checksum_pybcrypt(secret)

    def _calc_checksum_pybcrypt(self, secret):
        # py-bcrypt behavior:
        #   py2: unicode secret/hash encoded as ascii bytes before use,
        #        bytes taken as-is; returns ascii bytes.
        #   py3: unicode secret encoded as utf-8 bytes,
        #        hash encoded as ascii bytes, returns ascii unicode.
        config = self._get_config()
        hash = _pybcrypt.hashpw(secret, config)
        assert hash.startswith(config) and len(hash) == len(config)+31
        return str_to_uascii(hash[-31:])

    #---------------------------------------------------------------
    # bcryptor backend
    #---------------------------------------------------------------
    @classmethod
    def _load_backend_bcryptor(cls):
        # try to import bcryptor
        global _bcryptor_engine
        try:
            from bcryptor.engine import Engine as _bcryptor_engine
        except ImportError: # pragma: no cover
            return None
        return cls._calc_checksum_bcryptor

    def _calc_checksum_bcryptor(self, secret):
        # bcryptor behavior:
        #   py2: unicode secret/hash encoded as ascii bytes before use,
        #        bytes taken as-is; returns ascii bytes.
        #   py3: not supported
        if self.ident == IDENT_2:
            # bcryptor doesn't support $2$ hashes; but we can fake $2$ behavior
            # using the $2a$ algorithm, by repeating the password until
            # it's at least 72 chars in length.
            if secret:
                secret = repeat_string(secret, 72)
            config = self._get_config(IDENT_2A)
        else:
            config = self._get_config()
        hash = _bcryptor_engine(False).hash_key(secret, config)
        assert hash.startswith(config) and len(hash) == len(config)+31
        return str_to_uascii(hash[-31:])

    #---------------------------------------------------------------
    # os crypt() backend
    #---------------------------------------------------------------
    @classmethod
    def _load_backend_os_crypt(cls):
        # XXX: what to do if "2" isn't supported, but "2a" is?
        #      "2" is *very* rare, and can fake it using "2a"+repeat_string
        h1 = '$2$04$......................1O4gOrCYaqBG3o/4LnT2ykQUt1wbyju'
        h2 = '$2a$04$......................qiOQjkB8hxU8OzRhS.GhRMa4VUnkPty'
        if test_crypt("test", h1) and test_crypt("test", h2):
            return cls._calc_checksum_os_crypt
        return None

    def _calc_checksum_os_crypt(self, secret):
        config = self._get_config()
        hash = safe_crypt(secret, config)
        if hash:
            assert hash.startswith(config) and len(hash) == len(config)+31
            return hash[-31:]
        # get here mainly if 1) under py3, and 2) secret is latin-1 or other non-unicode bytes.
        # in this case, another backend like pybcrypt should be able to get around
        # py3's limitations.
        return self._try_alternate_backends(secret)

    #---------------------------------------------------------------
    # builtin backend
    #---------------------------------------------------------------
    @classmethod
    def _load_backend_builtin(cls):
        if os.environ.get("PASSLIB_BUILTIN_BCRYPT") not in ["enable","enabled"]:
            return None
        global _builtin_bcrypt
        if _builtin_bcrypt is None:
            from passlib.utils._blowfish import raw_bcrypt as _builtin_bcrypt
        return cls._calc_checksum_builtin

    def _calc_checksum_builtin(self, secret):
        chk = _builtin_bcrypt(secret, self.ident.strip("$"),
                              self.salt.encode("ascii"), self.rounds)
        return chk.decode("ascii")

    #===================================================================
    # eoc
    #===================================================================

_UDOLLAR = u("$")

class bcrypt_sha256(bcrypt):
    """This class implements a composition of BCrypt+SHA256, and follows the :ref:`password-hash-api`.

    It supports a fixed-length salt, and a variable number of rounds.

    The :meth:`~passlib.ifc.PasswordHash.encrypt` and :meth:`~passlib.ifc.PasswordHash.genconfig` methods accept
    all the same optional keywords as the base :class:`bcrypt` hash.

    .. versionadded:: 1.6.2
    """
    name = "bcrypt_sha256"

    # this is locked at 2a for now.
    ident_values = (IDENT_2A,)

    # sample hash:
    # $bcrypt-sha256$2a,6$/3OeRpbOf8/l6nPPRdZPp.$nRiyYqPobEZGdNRBWihQhiFDh1ws1tu
    # $bcrypt-sha256$           -- prefix/identifier
    # 2a                        -- bcrypt variant
    # ,                         -- field separator
    # 6                         -- bcrypt work factor
    # $                         -- section separator
    # /3OeRpbOf8/l6nPPRdZPp.    -- salt
    # $                         -- section separator
    # nRiyYqPobEZGdNRBWihQhiFDh1ws1tu  -- digest

    # XXX: we can't use .ident attr due to bcrypt code using it.
    #      working around that via prefix.
    prefix = u('$bcrypt-sha256$')

    _hash_re = re.compile(r"""
        ^
        [$]bcrypt-sha256
        [$](?P<variant>[a-z0-9]+)
        ,(?P<rounds>\d{1,2})
        [$](?P<salt>[^$]{22})
        ([$](?P<digest>.{31}))?
        $
        """, re.X)

    @classmethod
    def identify(cls, hash):
        hash = uh.to_unicode_for_identify(hash)
        if not hash:
            return False
        return hash.startswith(cls.prefix)

    @classmethod
    def from_string(cls, hash):
        hash = to_unicode(hash, "ascii", "hash")
        if not hash.startswith(cls.prefix):
            raise uh.exc.InvalidHashError(cls)
        m = cls._hash_re.match(hash)
        if not m:
            raise uh.exc.MalformedHashError(cls)
        rounds = m.group("rounds")
        if rounds.startswith(uh._UZERO) and rounds != uh._UZERO:
            raise uh.exc.ZeroPaddedRoundsError(cls)
        return cls(ident=m.group("variant"),
                   rounds=int(rounds),
                   salt=m.group("salt"),
                   checksum=m.group("digest"),
                   )

    def to_string(self):
        hash = u("%s%s,%d$%s") % (self.prefix, self.ident.strip(_UDOLLAR),
                                  self.rounds, self.salt)
        if self.checksum:
            hash = u("%s$%s") % (hash, self.checksum)
        return uascii_to_str(hash)

    def _calc_checksum(self, secret):
        # NOTE: this bypasses bcrypt's _calc_checksum,
        #       so has to take care of all it's issues, such as secret encoding.
        if isinstance(secret, unicode):
            secret = secret.encode("utf-8")
        # NOTE: can't use digest directly, since bcrypt stops at first NULL.
        # NOTE: bcrypt doesn't fully mix entropy for bytes 55-72 of password
        #       (XXX: citation needed), so we don't want key to be > 55 bytes.
        #       thus, have to use base64 (44 bytes) rather than hex (64 bytes).
        key = b64encode(sha256(secret).digest())
        return self._calc_checksum_backend(key)

    # patch set_backend so it modifies bcrypt class, not this one...
    # else it would clobber our _calc_checksum() wrapper above.
    @classmethod
    def set_backend(cls, *args, **kwds):
        return bcrypt.set_backend(*args, **kwds)

#=============================================================================
# eof
#=============================================================================
